Spring Security 一. 简介 Spring Security是一个能够为基于Spring的企业应用系统提供声明式的安全访问控制解决方案的安全框架。它提供了一组可以在Spring应用上下文中配置的Bean,充分利用了Spring IoC,DI(控制反转Inversion of Control ,DI:Dependency Injection 依赖注入)和AOP(面向切面编程)功能,为应用系统提供声明式的安全访问控制功能,减少了为企业系统安全控制编写大量重复代码的工作。
什么是ACL和RBAC
ACL: Access Control List 访问控制列表
以前盛行的一种权限设计,它的核心在于用户直接和权限挂钩
优点:简单易用,开发便捷
缺点:用户和权限直接挂钩,导致在授予时的复杂性,比较分散,不便于管理
例子:常见的文件系统权限设计, 直接给用户加权限
RBAC: Role Based Access Control
基于角色的访问控制系统。权限与角色相关联,用户通过成为适当角色的成员而得到这些角色的权限
优点:简化了用户与权限的管理,通过对用户进行分类,使得角色与权限关联起来
缺点:开发对比ACL相对复杂
例子:基于RBAC模型的权限验证框架与应用 Apache Shiro、spring Security
BAT企业 ACL,一般是对报表系统,阿里的ODPS
二. 入门案例 2.1 添加依赖 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 <parent > <groupId > org.springframework.boot</groupId > <artifactId > spring-boot-starter-parent</artifactId > <version > 2.1.6.RELEASE</version > </parent > <dependencies > <dependency > <groupId > org.springframework.boot</groupId > <artifactId > spring-boot-starter-web</artifactId > </dependency > <dependency > <groupId > org.springframework.boot</groupId > <artifactId > spring-boot-starter-security</artifactId > </dependency > <dependency > <groupId > mysql</groupId > <artifactId > mysql-connector-java</artifactId > </dependency > <dependency > <groupId > com.alibaba</groupId > <artifactId > druid-spring-boot-starter</artifactId > <version > 1.1.17</version > </dependency > <dependency > <groupId > org.springframework.boot</groupId > <artifactId > spring-boot-starter-jdbc</artifactId > </dependency > <dependency > <groupId > org.springframework.social</groupId > <artifactId > spring-social-web</artifactId > <version > 1.1.6.RELEASE</version > </dependency > </dependencies >
2.2 请求 我们任意编写一个接口,然后进行访问,会直接跳转到一个登录页面
三. 自定义用户登录处理 3.1 安全配置 1 2 3 4 5 6 7 8 9 10 11 12 13 @Configuration public class BrowserSecurityConfig extends WebSecurityConfigurerAdapter { @Override protected void configure(HttpSecurity http) throws Exception { http.formLogin() //采用表单登录 .and() .authorizeRequests() //请求认证 .anyRequest() //对于任何请求都需要认证 .authenticated(); //认证通过了才能访问 } }
3.2 自定义用户认证 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 @Component public class UserAuthentication implements UserDetailsService { @Resource private SysUserRepository sysUserRepository; @Override public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException { SysUser sysUser = sysUserRepository.findByNickyName(username); if (null == sysUser) { return new User(username, null, null); }else { return new User(username, sysUser.getPassword(), AuthorityUtils.commaSeparatedStringToAuthorityList("admin")); } } }
在实际的应用过程中,当我们发起请求的时候,springSecurity处理用户登录的过滤器是UsernamePasswordAuthenticationFilter这个过滤器,而这个过滤器会将用户提交的用户名和密码交由UserDetailsService的实现类来处理。具体的处理流程如下图所示:
3.3 密码加密校验 密码的加密校验需要实现PasswordEncoder这个接口,接口中有两个方法,
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 @Component public class CustomizePasswordEncoder implements PasswordEncoder { // 注册的时候使用, 人为的去调用 @Override public String encode(CharSequence rawPassword) { return null; } // 当在返回UserDetails,会自动的去实现校验 @Override public boolean matches(CharSequence rawPassword, String encodedPassword) { return false; } }
实际工作中我们可以直接使用spring security中默认的密码处理方式就完全可以满足日常的开发。
3.4 自定义登录页面 spring security中定义的登录页面有可能不满足需求,需要自己来实现一个登录页面,处理的方式为只需要在3.1节方法中 formLogin() 方法的后面加上loginPage()方法即可,如下代码所示:
1 2 3 4 5 6 7 8 9 10 @Override protected void configure(HttpSecurity http) throws Exception { http.formLogin() //采用表单登录 .loginPage("/login.html") .and() .authorizeRequests() //请求认证 .anyRequest() //对于任何请求都需要认真 .authenticated(); //认证通过了才能访问 }
这样配置会发现报如下的错误:
这个错误是很多的初学者容易犯的一个错误,原因是因为对于任何的页面都需要认证,所以就在这里无限循环下去了。我们需要接着调整代码,如下所示:
1 2 3 4 5 6 7 8 9 10 11 12 @Override protected void configure(HttpSecurity http) throws Exception { http.formLogin() //采用表单登录 .loginPage("/login.html") .loginProcessingUrl("/authentication/form") //登录页面提交的地址 .and() .authorizeRequests() //请求认证 .antMatchers("/login.html").permitAll() //如果是登录页面直接让其访问 .anyRequest() //对于任何请求都需要认真 .authenticated(); //认证通过了才能访问 }
3.5 编写自己的登录页面 1 2 3 4 5 <form action="/authentication/form" method="post"> Username: <input name="username"> <br> Password: <input name="password" type="password"> <br> <button>提交</button> </form>
当我们实现了自己的登录页面后发现还是无法登录,原因在于我们没有加上csrf(跨站请求伪造),我们暂时先将其禁用,代码如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 @Override protected void configure(HttpSecurity http) throws Exception { http.formLogin() //采用表单登录 .loginPage("/login.html") .loginProcessingUrl("/authentication/form") //登录页面提交的地址 .and() .authorizeRequests() //请求认证 .antMatchers("/login.html").permitAll() //如果是登录页面直接让其访问 .anyRequest() //对于任何请求都需要认真 .authenticated() //认证通过了才能访问 .and() .csrf().disable(); //关闭跨站请求伪造功能 }
四. 登录成功与失败处理 4.1 登录成功处理 在spring security中,当我们登录成功后默认是跳转到用户登录之前的请求,这个在当今SPA(Single Page Application)应用流行的今天,肯定是不适用的,我们需要的是异步的请求,返回登录成功的信息。
要实现用户登录成功处理,需要实现AuthenticationSuccessHandler这个接口,然后实现接口中的方法:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 @Component public class CustomizeAuthenticationSuccessHanler implements AuthenticationSuccessHandler { private Logger logger = LoggerFactory .getLogger(CustomizeAuthenticationSuccessHanler.class); @Autowired private ObjectMapper objectMapper; @Override public void onAuthenticationSuccess (HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException { logger.info("登录成功" ); response.setContentType("application/json;charset=utf-8" ); response.getWriter().write(objectMapper.writeValueAsString(authentication)); } }
4.2 登录失败处理 通过上面的演示我们能看到每次登录,还是回到登录页面,在异步请求下这种是无法满足我们的需求的,所以需要自定义登录失败处理。要实现AuthenticationFailureHandler这个接口,如下所示:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 @Component public class CustomerAuthenticationFailHandler implements AuthenticationFailureHandler { private Logger logger = LoggerFactory .getLogger(CustomerAuthenticationFailHandler.class); @Autowired private ObjectMapper objectMapper; @Override public void onAuthenticationFailure (HttpServletRequest request, HttpServletResponse response, AuthenticationException exception) throws IOException, ServletException { logger.info("登录失败" ); Map<String, Object> map = new HashMap<>(); map.put("code" , -1 ); map.put("msg" , "用户名或密码错误" ); response.setContentType("application/json;charset=utf-8" ); response.getWriter().write(objectMapper.writeValueAsString(map)); } }
安全配置代码如下:
五. 记住我 5.1 基本原理
在前端页面的请求参数必须叫remember-me
5.2 功能实现 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 @Autowired private UserDetailsService userAuthentication;@Autowired private DataSource dataSource;@Bean public PersistentTokenRepository persistentTokenRepository () { JdbcTokenRepositoryImpl jdbcTokenRepository = new JdbcTokenRepositoryImpl(); jdbcTokenRepository.setDataSource(dataSource); jdbcTokenRepository.setCreateTableOnStartup(true ); return jdbcTokenRepository; }
5.3 安全配置
六. 图片验证码 在实际的应用过程中,为了防止用户的恶意请求,我们通常都会设置图片验证码功能,而springsecurity并没有提供现有的实现,需要开发人员自行的实现。
6.1 封装验证码类 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 public class ImageCode { private BufferedImage bufferedImage; private String code; private LocalDateTime expireTime; public ImageCode (BufferedImage bufferedImage, String code, int seconds) { this .bufferedImage = bufferedImage; this .code = code; this .expireTime = LocalDateTime.now().plusSeconds(seconds); } public boolean isExpire () { return LocalDateTime.now().isAfter(expireTime); } }
6.2 请求控制类 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 @RestController public class ImageCodeController { private SessionStrategy sessionStrategy = new HttpSessionSessionStrategy(); public static final String IMAGE_CODE_SESSION_KEY = "IMAGE_CODE_SESSION_KEY" ; @RequestMapping ("/image/code" ) public void createCode (HttpServletRequest request, HttpServletResponse response) throws IOException { ImageCode imageCode = generate(); sessionStrategy.setAttribute(new ServletWebRequest(request), IMAGE_CODE_SESSION_KEY, imageCode); ImageIO.write(imageCode.getBufferedImage(), "JPEG" , response.getOutputStream()); } }
6.3 过滤器的编写 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 public class ValidataCodeFilter extends OncePerRequestFilter { private CustomerAuthenticationFailHandler customerAuthenticationFailHandler; public void setCustomerAuthenticationFailHandler (CustomerAuthenticationFailHandler customerAuthenticationFailHandler) { this .customerAuthenticationFailHandler = customerAuthenticationFailHandler; } private SessionStrategy sessionStrategy = new HttpSessionSessionStrategy(); @Override protected void doFilterInternal (HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { if (StringUtils.equals("/authentication/form" , request.getRequestURI()) && StringUtils.equals(request.getMethod(), "POST" )) { try { validate(new ServletWebRequest(request)); }catch (AuthenticationException exception) { customerAuthenticationFailHandler.onAuthenticationFailure(request, response, exception); return ; } } filterChain.doFilter(request, response); } public void validate (ServletWebRequest request) throws ServletRequestBindingException { ImageCode imageCode = (ImageCode) sessionStrategy.getAttribute(request, ImageCodeController.IMAGE_CODE_SESSION_KEY); String validateCode = ServletRequestUtils.getStringParameter(request.getRequest(), "codeImage" ); if (StringUtils.isEmpty(validateCode)) { throw new ValidateException("验证码不能为空" ); } if (imageCode == null ) { throw new ValidateException("验证码不存在" ); } if (imageCode.isExpire()) { throw new ValidateException("验证码过期" ); sessionStrategy.removeAttribute(request, ImageCodeController.IMAGE_CODE_SESSION_KEY); } if (!validateCode.equals(imageCode.getCode())) { throw new ValidateException("验证码不正确" ); } sessionStrategy.removeAttribute(request, ImageCodeController.IMAGE_CODE_SESSION_KEY); } }
6.4 登录异常处理 springSecurity中处理用户登录异常都应该由AuthenticationException这个异常来处理,所以我们需要自定义验证码校验失败的异常类:
1 2 3 4 5 6 public class ValidateException extends AuthenticationException { public ValidateException (String msg) { super (msg); } }
七. 手机号登录 手机号登录与用户名密码登录逻辑相同,所以我们在使用手机号登录系统的时候可以完全拷贝用户名密码登录的逻辑,那么前提是我们必须得搞懂用户名密码登录的逻辑。
7.1 编写Token 编写手机号认证Token, 模仿UsernamePasswordAuthenticationToken这个类来实现。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 public class SmsAuthenticationToken extends AbstractAuthenticationToken { private static final long serialVersionUID = SpringSecurityCoreVersion.SERIAL_VERSION_UID; private final Object principal; public SmsAuthenticationToken (Object principal) { super (null ); this .principal = principal; setAuthenticated(false ); } public SmsAuthenticationToken (Object principal, Collection<? extends GrantedAuthority> authorities) { super (authorities); this .principal = principal; super .setAuthenticated(true ); } @Override public Object getCredentials () { return null ; } public Object getPrincipal () { return this .principal; } public void setAuthenticated (boolean isAuthenticated) throws IllegalArgumentException { if (isAuthenticated) { throw new IllegalArgumentException( "Cannot set this token to trusted - use constructor which takes a GrantedAuthority list instead" ); } super .setAuthenticated(false ); } @Override public void eraseCredentials () { super .eraseCredentials(); } }
7.2 编写Filter 手机号的过滤器可以模仿 UsernamePasswordAuthenticationFilter 来实现。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 public class SmsAuthenticationFilter extends AbstractAuthenticationProcessingFilter { public static final String SPRING_SECURITY_FORM_MOBILE_KEY = "mobile" ; private String mobileParameter = SPRING_SECURITY_FORM_MOBILE_KEY; private boolean postOnly = true ; public SmsAuthenticationFilter () { super (new AntPathRequestMatcher("/authentication/mobile" , "POST" )); } public Authentication attemptAuthentication (HttpServletRequest request, HttpServletResponse response) throws AuthenticationException { if (postOnly && !request.getMethod().equals("POST" )) { throw new AuthenticationServiceException( "Authentication method not supported: " + request.getMethod()); } String mobile = obtainMobile(request); if (mobile == null ) { mobile = "" ; } mobile = mobile.trim(); SmsAuthenticationToken authRequest = new SmsAuthenticationToken(mobile); setDetails(request, authRequest); return this .getAuthenticationManager().authenticate(authRequest); } protected String obtainMobile (HttpServletRequest request) { return request.getParameter(mobileParameter); } protected void setDetails (HttpServletRequest request, SmsAuthenticationToken authRequest) { authRequest.setDetails(authenticationDetailsSource.buildDetails(request)); } public void setMobileParameter (String mobileParameter) { Assert.hasText(mobileParameter, "Mobile parameter must not be empty or null" ); this .mobileParameter = mobileParameter; } public void setPostOnly (boolean postOnly) { this .postOnly = postOnly; } }
7.3 Provider Provider的作用是用来处理对应的Token,校验用户名密码使用的Provider为DaoAuthenticationProvider, 在实现我们自己的Provider的时候,我们去实现AuthenticationProvider。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 public class SmsCredentialsProvider implements AuthenticationProvider { private UserDetailsService userDetailsService; public UserDetailsService getUserDetailsService () { return userDetailsService; } public void setUserDetailsService (UserDetailsService userDetailsService) { this .userDetailsService = userDetailsService; } @Override public Authentication authenticate (Authentication authentication) throws AuthenticationException { SmsAuthenticationToken smsAuthenticationToken = (SmsAuthenticationToken)authentication; UserDetails user = userDetailsService.loadUserByUsername((String)smsAuthenticationToken.getPrincipal()); if (null == user) { throw new InternalAuthenticationServiceException("无法获取用户信息" ); } SmsAuthenticationToken token = new SmsAuthenticationToken(user, user.getAuthorities()); token.setDetails(smsAuthenticationToken.getDetails()); return token; } @Override public boolean supports (Class<?> authentication) { return SmsAuthenticationToken.class.isAssignableFrom(authentication); } }
7.4 发送短信实现 接口的实现
1 2 3 4 public interface SmsCodeSender { void send (String code, String mobile) ; }
实现类
1 2 3 4 5 6 7 public class DefaultSmsCodeSender implements SmsCodeSender { @Override public void send (String code, String mobile) { System.out.println("往手机 " + mobile + " 上发送的验证码为: " + code); } }
7.5 配置Filter以及Provider 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 @Component public class SmsCodeAuthenticationConfig extends SecurityConfigurerAdapter <DefaultSecurityFilterChain , HttpSecurity > { @Autowired private CustomizeAuthenticationSuccessHanler customizeAuthenticationSuccessHanler; @Autowired private CustomerAuthenticationFailHandler customerAuthenticationFailHandler; @Autowired private UserDetailsService userAuthentication; @Override public void configure (HttpSecurity http) throws Exception { SmsAuthenticationFilter smsAuthenticationFilter = new SmsAuthenticationFilter(); smsAuthenticationFilter.setAuthenticationManager(http.getSharedObject(AuthenticationManager.class)); smsAuthenticationFilter.setAuthenticationSuccessHandler(customizeAuthenticationSuccessHanler); smsAuthenticationFilter.setAuthenticationFailureHandler(customerAuthenticationFailHandler); SmsCredentialsProvider smsCredentialsProvider = new SmsCredentialsProvider(); smsCredentialsProvider.setUserDetailsService(userAuthentication); http.authenticationProvider(smsCredentialsProvider) .addFilterAfter(smsAuthenticationFilter, UsernamePasswordAuthenticationFilter.class); } }
7.6 安全配置
短信验证码的过滤器和图片验证码的逻辑是相同,故在此不作处理。
7.7 页面的实现
八. session管理 8.1 session并发控制 session的失效时间默认为30min,可以通过 server.servlet.session.timeout类配置。在很多的业务场景下,我们只允许一台设备登录到服务端。
安全配置
session失效处理逻辑
1 2 3 4 5 6 7 8 9 10 11 12 13 public class MultipleSessionHandler implements SessionInformationExpiredStrategy { @Override public void onExpiredSessionDetected (SessionInformationExpiredEvent event) throws IOException, ServletException { HttpServletResponse response = event.getResponse(); response.setContentType("text/plain;charset=utf-8" ); response.getWriter().write("其他设备登录" ); } }
8.2 session集群管理 当我们在集群环境下,用户每次的请求我们并不能保证每次都是到达同一台服务器,可能会导致session存在于不同的服务器上,而让用户重新进行登录,所以必须要采用一个中间件来存储用户的session信息,企业中使用最多的就是redis.
依赖
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 <dependency > <groupId > org.springframework.boot</groupId > <artifactId > spring-boot-starter-data-redis</artifactId > </dependency > <dependency > <groupId > org.springframework.session</groupId > <artifactId > spring-session-core</artifactId > <version > 2.1.9.RELEASE</version > </dependency > <dependency > <groupId > org.springframework.session</groupId > <artifactId > spring-session-data-redis</artifactId > <version > 2.1.9.RELEASE</version > </dependency > <dependency > <groupId > org.apache.commons</groupId > <artifactId > commons-pool2</artifactId > <version > 2.7.0</version > </dependency >
applicatoin.yml配置
1 2 3 4 5 6 7 8 9 10 11 12 spring: redis: port: 6379 host: localhost password: lettuce: pool: min-idle: 2 max-active: 8 session: store-type: redis
九. 退出登录 1 2 3 .logout() // .logoutSuccessUrl("/login.html") //退出后跳转的页面 .and()
十. 权限管理 权限是大部分的后台管理系统都需要实现的功能,用户控制不同的角色能够进行的不同的操作。Spring Security的可以进行用户的角色权限控制,也可以进行用户的操作权限控制。在之前的代码实现上,我们仅仅只是实现用户的登录,在用户信息验证的时候使用UserDetailsService,但是却一直忽略了用户的权限。
10.1 启动类配置 1 2 3 4 5 6 7 8 9 10 11 12 13 14 @SpringBootApplication @EnableGlobalMethodSecurity (securedEnabled = true , jsr250Enabled = true , prePostEnabled = true )public class SecurityApplication { public static void main (String[] args) { SpringApplication.run(SecurityApplication.class, args); } }
10.2 基于角色的权限控制 用户权限的查询
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 @Component public class UserSecurityService implements UserDetailsService { @Override public UserDetails loadUserByUsername (String username) throws UsernameNotFoundException { return new User(username, sysUser.getPassword(), Arrays.asList(new SimpleGrantedAuthority("ROLE_admin" ))); } }
我们在构建SimpleGrantedAuthority对象的时候,用户的角色必须是以 ROLE_
开头,例如 ROLE_admin
、ROLE_manager
控制器角色控制
在控制器上进行用户访问控制的时候,基于角色有两种书写方式:
方式一:@RolesAllowed
1 2 3 4 5 6 7 8 9 @RequestMapping @RolesAllowed ("admin" )public Object getAll () { return Arrays.asList(new User(10 , "张" ), new User(20 , "李四" )); }
方式二:
1 2 3 4 5 6 7 8 @RequestMapping @Secured ("ROLE_admin" )public Object getAll () { return Arrays.asList(new User(10 , "张" ), new User(20 , "李四" )); }
10.3 基于操作的权限控制 当然我们也可以使用基于操作的权限控制,这个功能稍显得有点累赘,因为在实际的项目开发过程中我们都是基于角色的权限控制。
用户权限查询
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 @Component public class UserSecurityService implements UserDetailsService { @Override public UserDetails loadUserByUsername (String username) throws UsernameNotFoundException { return new User(username, sysUser.getPassword(), Arrays.asList(new SimpleGrantedAuthority("user:list" ), new SimpleGrantedAuthority("user:add" ) )); } }
控制器访问控制(针对角色)
1 2 3 4 5 6 7 8 9 10 @RequestMapping @PreAuthorize ("hasRole('admin')" )public Object getAll () { return Arrays.asList(new User(10 , "张" ), new User(20 , "李四" )); }
控制器访问控制(针对操作)
1 2 3 4 5 6 7 @RequestMapping @PreAuthorize ("hasAnyAuthority('user:add', 'user:list')" ) public Object getAll () { return Arrays.asList(new User(10 , "张" ), new User(20 , "李四" )); }
10.4 访问无权限处理 1 2 3 4 .and() .exceptionHandling() .accessDeniedHandler(customizeAccessDeniedHandler) .and()